Micron Document
🎖️GitЯра🎖️

Commit 3d5ff661b8b11e1ded28c92c6c8a7489d1a0459f


Parents : 3dfc8ae
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-24T13:02:29-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-24T13:02:29-05:00

docs: version docs site with persistent channels and a version switcher (#6410)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 1fc764cdc6..d8feed04c3 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -1,5 +1,9 @@
name: Deploy Documentation
+# Publishes the main-branch docs snapshot to /main/ (and refreshed Dokka to
+# /api/) on the persistent gh-pages branch. The site root (latest release)
+# and /vX.Y.Z/ folders are owned by docs-release.yml and are left untouched.
+
on:
push:
branches: [main]
@@ -17,20 +21,22 @@ on:
- 'build.gradle.kts'
- 'settings.gradle.kts'
- '.github/workflows/docs-deploy.yml'
+ - 'scripts/docs/**'
workflow_dispatch:
permissions:
- contents: read
- pages: write
- id-token: write
+ contents: write
+# Shares the group with docs-release.yml so gh-pages pushes serialize.
+# cancel-in-progress must stay false: a snapshot deploy must never cancel an
+# in-flight release publish (GitHub still coalesces queued runs to one).
concurrency:
group: pages
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
- build:
+ publish:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04
timeout-minutes: 45
@@ -53,8 +59,8 @@ jobs:
bundler-cache: true
working-directory: docs
- - name: Generate Docs Site
- run: ./gradlew generateDocsBundle validateDocsBundle publishDocsSite -Pdocs.channel=root -Pci=true
+ - name: Generate Docs Site (main channel)
+ run: ./gradlew generateDocsBundle validateDocsBundle publishDocsSite -Pdocs.channel=main -Pci=true
# Dokka (Gradle) and Jekyll (Ruby) are independent — Dokka's output is only
# copied in afterwards — so run them concurrently to overlap the two slowest
@@ -63,32 +69,18 @@ jobs:
run: |
set -euo pipefail
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
- --source build/_site \
+ --source build/_site/main \
--destination build/jekyll_site \
- --baseurl /${{ github.event.repository.name }} &
+ --baseurl /${{ github.event.repository.name }}/main &
jekyll_pid=$!
./gradlew dokkaGeneratePublicationHtml --no-configuration-cache
wait "$jekyll_pid"
- touch build/jekyll_site/.nojekyll
-
- - name: Assemble Pages artifact
- run: cp -r build/dokka/html build/jekyll_site/api # Dokka HTML -> /api
-
- - name: Upload Pages Artifact
- uses: actions/upload-pages-artifact@v5
- with:
- path: build/jekyll_site/
- deploy:
- if: github.repository == 'meshtastic/Meshtastic-Android'
- needs: build
- runs-on: ubuntu-24.04-arm
- timeout-minutes: 15
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v5
+ - name: Stage channels
+ run: |
+ mkdir -p build/pages_staging
+ cp -r build/jekyll_site build/pages_staging/main
+ cp -r build/dokka/html build/pages_staging/api
+ - name: Publish to gh-pages
+ run: scripts/docs/publish-to-gh-pages.sh build/pages_staging main api

diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml
index bcc05dce10..6430d9dfaa 100644
--- a/.github/workflows/docs-release.yml
+++ b/.github/workflows/docs-release.yml
@@ -1,22 +1,29 @@
name: Docs Release
+# Publishes release docs to the persistent gh-pages branch: the site root
+# (latest release) and a permanent /vX.Y.Z/ copy, plus refreshed Dokka at
+# /api/. The /main/ snapshot is owned by docs-deploy.yml and left untouched.
+#
+# workflow_dispatch exists for backfill: run it against a vX.Y.Z tag ref to
+# (re)publish that version without cutting a new tag.
+
on:
push:
tags:
- 'v*.*.*'
- '!v*-*'
+ workflow_dispatch:
+
permissions:
- contents: read
- pages: write
- id-token: write
+ contents: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
- build:
+ publish:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04
timeout-minutes: 45
@@ -30,6 +37,11 @@ jobs:
- name: Extract Version
id: version
run: |
+ if ! [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "This workflow must run against a vX.Y.Z tag ref (got $GITHUB_REF)." >&2
+ echo "For workflow_dispatch, select the release tag under 'Use workflow from'." >&2
+ exit 1
+ fi
TAG=${GITHUB_REF#refs/tags/v}
echo "version=$TAG" >> $GITHUB_OUTPUT
echo "Deploying docs for version: $TAG"
@@ -65,7 +77,7 @@ jobs:
--source build/_site/v${{ steps.version.outputs.version }} \
--destination build/jekyll_release \
--baseurl /${{ github.event.repository.name }}/v${{ steps.version.outputs.version }}
-
+
# Move versioned source folder out of root source folder to avoid nested build issues
mv build/_site/v${{ steps.version.outputs.version }} build/v_temp
@@ -75,39 +87,12 @@ jobs:
--destination build/jekyll_root \
--baseurl /${{ github.event.repository.name }}
- - name: Assemble Pages artifact
+ - name: Stage channels
run: |
- # Create final site directory
- mkdir -p build/final_site
-
- # Copy root compiled site
- cp -r build/jekyll_root/* build/final_site/
-
- # Copy release compiled site to vX.Y.Z/
- mkdir -p build/final_site/v${{ steps.version.outputs.version }}
- cp -r build/jekyll_release/* build/final_site/v${{ steps.version.outputs.version }}/
-
- # Copy Dokka output into final_site/api/
- cp -r build/dokka/html build/final_site/api
-
- # Disable GitHub Pages' own Jekyll build
- touch build/final_site/.nojekyll
-
- - name: Upload Pages Artifact
- uses: actions/upload-pages-artifact@v5
- with:
- path: build/final_site/
-
- deploy:
- if: github.repository == 'meshtastic/Meshtastic-Android'
- needs: build
- runs-on: ubuntu-24.04-arm
- timeout-minutes: 15
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v5
+ mkdir -p build/pages_staging
+ cp -r build/jekyll_root build/pages_staging/root
+ cp -r build/jekyll_release build/pages_staging/v${{ steps.version.outputs.version }}
+ cp -r build/dokka/html build/pages_staging/api
+ - name: Publish to gh-pages
+ run: scripts/docs/publish-to-gh-pages.sh build/pages_staging root v${{ steps.version.outputs.version }} api

diff --git a/docs/README.md b/docs/README.md
index 7db6d42bfc..e5d262d1a2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -42,3 +42,23 @@ docs/
4. At build time, the Gradle `syncTranslatedDocsToComposeResources` task bundles them into
locale-qualified Compose Resources for the in-app reader
5. The in-app `DocBundleLoader` tries the user's locale first, then falls back to English
+
+## Publishing & Versioning
+
+The GitHub Pages site is published to the persistent `gh-pages` branch as parallel
+channels (GitHub Pages must be configured to serve from that branch):
+
+| Path | Content | Published by |
+|------|---------|--------------|
+| `/` | Latest published release (default landing) | `docs-release.yml` on `vX.Y.Z` tags |
+| `/vX.Y.Z/` | Permanent per-release copy | `docs-release.yml` on `vX.Y.Z` tags |
+| `/main/` | Snapshot of the `main` branch | `docs-deploy.yml` on pushes to `main` |
+| `/api/` | Dokka API reference | both workflows |
+| `/versions.json` | Version manifest for the site's version switcher | regenerated on every deploy |
+
+Each deploy overlays only its own channels via `scripts/docs/publish-to-gh-pages.sh`,
+so release history accumulates instead of being wiped by the next deploy. The header
+version dropdown (`_includes/version_switcher.html`) reads `/versions.json` at runtime;
+a separate header link points to the upstream docs at meshtastic.org. To backfill a
+release (e.g. after first enabling this), run the "Docs Release" workflow manually
+against the release tag.

diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html
index f291d4d171..40e0ff24ad 100644
--- a/docs/_includes/head_custom.html
+++ b/docs/_includes/head_custom.html
@@ -153,6 +153,35 @@
.language-switcher-list a:hover {
background-color: rgba(103, 234, 148, 0.15);
}
+
+ /* Version switcher (shares the language-switcher dropdown styling) */
+ .version-switcher {
+ margin-left: 8px;
+ }
+
+ .version-switcher[hidden] {
+ display: none;
+ }
+
+ /* Link out to the upstream meshtastic.org docs */
+ .upstream-docs-link {
+ display: inline-flex;
+ align-items: center;
+ margin-left: 8px;
+ padding: 4px 10px;
+ border: 1px solid var(--border-color, #D5D6E0);
+ border-radius: 16px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ font-family: var(--font-sans);
+ text-decoration: none;
+ color: inherit;
+ transition: background-color 0.2s ease;
+ }
+
+ .upstream-docs-link:hover {
+ background-color: rgba(128, 128, 128, 0.1);
+ }
</style>
<script>

diff --git a/docs/_includes/header_custom.html b/docs/_includes/header_custom.html
index b19cdf3bc8..cad8670137 100644
--- a/docs/_includes/header_custom.html
+++ b/docs/_includes/header_custom.html
@@ -3,7 +3,9 @@
<span id="theme-icon">🌙</span>
<span id="theme-label">Dark</span>
</button>
+ {% include version_switcher.html %}
{% include language_switcher.html %}
+ <a class="upstream-docs-link" href="https://meshtastic.org/docs/" title="Official Meshtastic documentation (meshtastic.org)">Meshtastic Docs ↗</a>
</div>
<script>

diff --git a/docs/_includes/version_switcher.html b/docs/_includes/version_switcher.html
new file mode 100644
index 0000000000..dfe5c0f69b
--- /dev/null
+++ b/docs/_includes/version_switcher.html
@@ -0,0 +1,76 @@
+{% comment %}
+ Version switcher for the published docs site.
+
+ The site is deployed as parallel channels on gh-pages:
+ / -> latest published release (default)
+ /main/ -> snapshot of the main branch
+ /vX.Y.Z/ -> each published release
+
+ Which channel this build belongs to is inferred from site.baseurl
+ (e.g. "/Meshtastic-Android", "/Meshtastic-Android/main",
+ "/Meshtastic-Android/v2.9.0"). The available versions are read at runtime
+ from /versions.json at the site root, which is regenerated on every deploy
+ by scripts/docs/publish-to-gh-pages.sh. When versions.json is unavailable
+ (e.g. local `jekyll serve`), the switcher stays hidden.
+{% endcomment %}
+
+<details class="language-switcher version-switcher" id="version-switcher" aria-label="Documentation version" hidden>
+ <summary class="language-switcher-btn" title="Switch documentation version">
+ 🏷️ <span id="version-current"></span>
+ </summary>
+ <ul class="language-switcher-list" id="version-switcher-list"></ul>
+</details>
+
+<script>
+(function() {
+ var baseurl = '{{ site.baseurl }}';
+ var root = baseurl;
+ var channel = 'latest';
+ var match = baseurl.match(/^(.*)\/(main|v\d+\.\d+\.\d+)$/);
+ if (match) {
+ root = match[1];
+ channel = match[2];
+ }
+
+ fetch(root + '/versions.json')
+ .then(function(res) { return res.ok ? res.json() : Promise.reject(); })
+ .then(function(data) {
+ var switcher = document.getElementById('version-switcher');
+ var list = document.getElementById('version-switcher-list');
+ var current = document.getElementById('version-current');
+ if (!switcher || !list || !current) return;
+
+ // Keep the reader on the same page when switching channels; a page
+ // that doesn't exist in the target version lands on the 404 page.
+ var relativePath = location.pathname.slice(baseurl.length) + location.hash;
+
+ var entries = [];
+ if (data.latest) {
+ entries.push({ id: 'latest', label: 'latest (v' + data.latest + ')', base: root });
+ }
+ if (data.hasMain) {
+ entries.push({ id: 'main', label: 'main (snapshot)', base: root + '/main' });
+ }
+ (data.versions || []).forEach(function(v) {
+ entries.push({ id: 'v' + v, label: 'v' + v, base: root + '/v' + v });
+ });
+
+ var currentEntry = entries.filter(function(e) { return e.id === channel; })[0];
+ current.textContent = currentEntry ? currentEntry.label : channel;
+
+ var others = entries.filter(function(e) { return e.id !== channel; });
+ if (others.length === 0) return;
+
+ others.forEach(function(e) {
+ var li = document.createElement('li');
+ var a = document.createElement('a');
+ a.href = e.base + relativePath;
+ a.textContent = e.label;
+ li.appendChild(a);
+ list.appendChild(li);
+ });
+ switcher.hidden = false;
+ })
+ .catch(function() { /* no manifest (local build) — leave switcher hidden */ });
+})();
+</script>

diff --git a/scripts/docs/publish-to-gh-pages.sh b/scripts/docs/publish-to-gh-pages.sh
new file mode 100755
index 0000000000..b394e1bfa7
--- /dev/null
+++ b/scripts/docs/publish-to-gh-pages.sh
@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+# Publish one or more docs channels to the persistent gh-pages branch.
+#
+# Unlike the artifact-based deploy-pages model (which replaces the whole site
+# on every deploy), this overlays ONLY the channels being rebuilt, so
+# previously published /vX.Y.Z/ folders survive main-branch snapshot deploys
+# and vice versa.
+#
+# Usage: publish-to-gh-pages.sh <staging-dir> <channel> [<channel>...]
+#
+# <staging-dir> directory containing one subdirectory per channel:
+# staging/root/ -> site root (latest release docs)
+# staging/main/ -> /main/ (snapshot of main branch)
+# staging/api/ -> /api/ (Dokka)
+# staging/vX.Y.Z/ -> /vX.Y.Z/ (versioned release docs)
+# <channel> subdirectory names to publish (root|main|api|vX.Y.Z)
+#
+# Must run from inside the repo checkout (uses a git worktree so the
+# credentials persisted by actions/checkout apply to the push).
+# Requires: rsync, python3.
+
+set -euo pipefail
+
+STAGING=$(cd "$1" && pwd)
+shift
+CHANNELS=("$@")
+
+if [ ${#CHANNELS[@]} -eq 0 ]; then
+ echo "No channels given" >&2
+ exit 1
+fi
+
+SITE_DIR=$(mktemp -d)
+cleanup() {
+ git worktree remove --force "$SITE_DIR" 2> /dev/null || rm -rf "$SITE_DIR"
+}
+trap cleanup EXIT
+
+# Check out existing gh-pages content, or start an orphan history.
+rmdir "$SITE_DIR"
+if git fetch origin gh-pages 2> /dev/null; then
+ git worktree add -B gh-pages "$SITE_DIR" FETCH_HEAD
+else
+ git worktree add --detach "$SITE_DIR"
+ git -C "$SITE_DIR" checkout --orphan gh-pages
+ git -C "$SITE_DIR" rm -rf --quiet . || true
+fi
+
+for channel in "${CHANNELS[@]}"; do
+ src="$STAGING/$channel"
+ if [ ! -d "$src" ]; then
+ echo "Missing staging dir for channel '$channel': $src" >&2
+ exit 1
+ fi
+ if [ "$channel" = "root" ]; then
+ # Replace root files but preserve the other channels living beside them.
+ # --checksum: freshly built files and the just-checked-out worktree can
+ # share size + same-second mtime, which defeats rsync's quick check.
+ rsync -a --checksum --delete \
+ --exclude='.git' \
+ --exclude='main/' \
+ --exclude='api/' \
+ --exclude='v[0-9]*/' \
+ --exclude='versions.json' \
+ "$src/" "$SITE_DIR/"
+ else
+ rsync -a --checksum --delete --exclude='.git' "$src/" "$SITE_DIR/$channel/"
+ fi
+ echo "Published channel: $channel"
+done
+
+# Until the first release deploy lands, the site root has no content of its
+# own — point it at the main snapshot so the Pages URL isn't a 404.
+if [ ! -f "$SITE_DIR/index.html" ] && [ -d "$SITE_DIR/main" ]; then
+ cat > "$SITE_DIR/index.html" << 'EOF'
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="refresh" content="0; url=./main/">
+ <title>Meshtastic Android Docs</title>
+</head>
+<body><a href="./main/">Redirecting to the main-branch docs…</a></body>
+</html>
+EOF
+ echo "Wrote placeholder root index.html -> main/ (no release published yet)"
+fi
+
+# Regenerate the version manifest consumed by the docs site's version
+# switcher. Latest = highest semver among published vX.Y.Z folders.
+python3 - "$SITE_DIR" << 'EOF'
+import json, os, re, sys
+
+site = sys.argv[1]
+pattern = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
+versions = sorted(
+ (m.groups() for d in os.listdir(site) if (m := pattern.match(d)) and os.path.isdir(os.path.join(site, d))),
+ key=lambda g: tuple(map(int, g)),
+ reverse=True,
+)
+manifest = {
+ "latest": ".".join(versions[0]) if versions else None,
+ "versions": [".".join(v) for v in versions],
+ "hasMain": os.path.isdir(os.path.join(site, "main")),
+}
+with open(os.path.join(site, "versions.json"), "w") as f:
+ json.dump(manifest, f, indent=2)
+print(f"versions.json: {manifest}")
+EOF
+
+# Pages must serve this branch as-is; the site is already built.
+touch "$SITE_DIR/.nojekyll"
+
+cd "$SITE_DIR"
+git add -A
+if git diff --cached --quiet; then
+ echo "No changes to publish"
+ exit 0
+fi
+git -c user.name='github-actions[bot]' \
+ -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
+ commit -m "docs: publish channels: ${CHANNELS[*]}"
+git push origin HEAD:gh-pages

Served by rngit 1.5.4 - Generated in 0.06s